library(rlang)
library(purrr)
#>
#> Attaching package: 'purrr'
#> The following objects are masked from 'package:rlang':
#>
#> %@%, flatten, flatten_chr, flatten_dbl, flatten_int,
#> flatten_lgl, flatten_raw, invoke, splice20 Evaluation
Introduction
“引用”有两个反面——“解引用”和“评估”。通常,“解引用”面向使用者,赋予了使用者选择性评估“表达式”的能力;而“评估”面向开发者,赋予了开发者在自定义环境中评估“表达式”的能力。
本章从最纯粹的评估模式开始讨论,介绍eval()如何在环境中评估一个表达式,及如何使用它实现许多重要的base R 函数。然后延申评估,介绍两种重要思想:
Quoseure: 一种用于捕获表达式及其关联环境的数据结构。
数据掩码:使在“数据框环境”中评估表达式更加容易。
总之,准引用、quosure、数据掩码共同构成了我们所说的整洁评估(tidy-eval)。整洁评估为非标准评估提供了一种原则性的方法,使得可以交互使用这些函数并将其与其他函数进行嵌套。整洁评估是所有这些理论中最重要的实际含义,因此将花一些时间来探讨它们的含义。本章最后讨论了base R的最相关方法,以及如何围绕它们的缺点进行编程。
Outline
20.2节:介绍
base::eval()函数,及如何使用它实现local()和source()。20.3节:介绍quosure数据结构,及如何生成与评估它。
20.4节:介绍数据掩码和避免歧义的声明。
20.5节:介绍使用整洁评估的实例。
20.6节:介绍base R中的非标准性评估及其缺陷。
Prerequisites
要求熟悉前两章内容和第7章有关环境的内容。
Evaluation basics
eval()函数有两个参数:epxr,env。
expr参数是待评估的“表达式”或符号。由于eval()函数不会对输入引用,所以需要与expr()一同使用:
x <- 10
eval(expr(x))
#> [1] 10
y <- 2
eval(expr(x + y))
#> [1] 12env参数用来指定评估“表达式”的“环境”,如果没有指定,则默认为当前环境。
eval(expr(x + y), env(x = 1000))
#> [1] 1002当指定了环境,却没有引用输入时,会导致错误的结果:
eval(print(x + 1), env(x = 1000))
#> [1] 11
#> [1] 11
eval(expr(print(x + 1)), env(x = 1000))
#> [1] 1001在了解了基础知识后,让我们探索一些应用,根据底层原理重新实现base R中的某些函数。
Application:local()
有时我们会创建一些临时变量来执行一系列计算,这些临时变量不会长期使用,可能也会相当占用内存,需要在使用结束后删除。一种方法是使用rm()清楚临时变量;另一种是将计算过程打包为函数,仅调用一次。更优雅的方式是使用local()函数,它可以创建一个临时环境,并执行其中的代码。
# Clean up variables created earlier
rm(x, y)
foo <- local({
x <- 10
y <- 200
x + y
})
foo
#> [1] 210
x
#> Error: object 'x' not found
y
#> Error: object 'y' not foundlocal()函数的本质很简单,我们可以采用下面的策略实现它。首先捕获输入的“表达式”,然后使用local()函数的执行环境作为eval()的调用环境参与评估。
local2 <- function(expr) {
env <- env(caller_env())
eval(enexpr(expr), env)
}
foo <- local2({
x <- 10
y <- 200
x + y
})
foo
#> [1] 210
x
#> Error: object 'x' not found
y
#> Error: object 'y' not found但base::local()的底层实现很复杂,它使用了eval()和substitute()。
Application:source()
我们可以通过组合eval()和parse_expr()来实现source()的功能。首先从磁盘中读取文件,然后使用parse_expr()将字符串转换成“表达式”列表,最后使用eval()评估“表达式”。实现如下:
source2 <- function(path, env = caller_env()) {
file <- paste(readLines(path, warn = FALSE), collapse = "\n")
exprs <- parse_exprs(file)
res <- NULL
for (i in seq_along(exprs)) {
res <- eval(exprs[[i]], env)
}
invisible(res)
}真实的base::source()函数更加复杂,会打印输入输出信息,同时有许多额外参数控制行为。
Expression vectors
上一章讲到,base::parse()函数解析字符串时,如果捕获到多个“表达式”,会返回一个包含多个表达式的向量。base::eval()函数可以直接评估这个向量,而不用上面的for循环。
source3 <- function(file, env = parent.frame()) {
lines <- parse(file)
res <- eval(lines, envir = env)
invisible(res)
}Gotcha:function()
如果你使用eval()和expr()来生成函数,有一个小小的漏洞需要注意:
x <- 10
y <- 20
f <- eval(expr(function(x, y) !!x + !!y))
f
#> function (x, y)
#> 10 + 20这个函数看起来不像能正常运行,其实可以:
f()
#> [1] 30这是因为,如果函数有“srcref”属性,就会打印它,但“srcref”是一个base R的特性,它无法识别准引用。
要解决这个问题,可以使用new_function()或删除“srcref”属性:
attr(f, "srcref") <- NULL
f
#> function (x, y)
#> 10 + 20Quosures
几乎eval()的所有使用都包括“表达式”和“环境”两个参数,但是base R中没有能同时提供这两个参数的数据结构,“rlang”包创建了这种数据结构——“quosures”。quosures是“quoting”和“closure”的复合体,意味着它同时包含了“表达式”和环境。
在本节中,你将学习如何创建和操作quosure, 以及一些关于如何实现它。
Creating
有三中方式创建quosure:
- 使用
enquo()和enquos(),它们会同时捕获表达式和环境。许多quosure都是由此创建的。
foo <- function(x) enquo(x)
foo(a + b)
#> <quosure>
#> expr: ^a + b
#> env: global- 使用
quo()和quos(),与enquo()和enquos()的关系可以参考expr()``enexpr()。使用的场景很少。
quo(x + y + z)
#> <quosure>
#> expr: ^x + y + z
#> env: global- 使用
new_quosure(),输入“表达式”和环境来创建quosure。使用场景也极少。
new_quosure(expr(x + y), env(x = 1, y = 10))
#> <quosure>
#> expr: ^x + y
#> env: 0x000001c76004dd30Evaluting
只能使用eval_tidy()来评估quosure。
q1 <- new_quosure(expr(x + y), env(x = 1, y = 10))
eval_tidy(q1)
#> [1] 11Dots
enquos()可以正确识别...中传入的参数及其绑定的环境。例如,下面的qs对象,正确评估了global和f的环境。
f <- function(...) {
x <- 1
g(..., f = x)
}
g <- function(...) {
enquos(...)
}
x <- 0
qs <- f(global = x)
qs
#> <list_of<quosure>>
#>
#> $global
#> <quosure>
#> expr: ^x
#> env: global
#>
#> $f
#> <quosure>
#> expr: ^x
#> env: 0x000001c75ec5b668
map_dbl(qs, eval_tidy)
#> global f
#> 0 1Under the hood
Quosures 数据结构受R中的“formulas”启发,因为“formula”同样也是同时捕获“表达式”与“环境”。早期也确实使用“formula”来进行评估,但因为无法简单的将~变为准引用函数,所以放弃使用“formula”。
f <- ~ runif(3)
str(f)
#> Class 'formula' language ~runif(3)
#> ..- attr(*, ".Environment")=<environment: R_GlobalEnv>Quosures 同样也是“formula”的子类:
q4 <- new_quosure(expr(x + y + z))
class(q4)
#> [1] "quosure" "formula"这意味着一些函数可以直接作用于Quosures:
is_call(q4)
#> [1] TRUE
q4[[1]]
#> Warning: Subsetting quosures with `[[` is deprecated as of rlang 0.4.0
#> Please use `quo_get_expr()` instead.
#> This warning is displayed once every 8 hours.
#> `~`
q4[[2]]
#> x + y + z有一个用于存放环境的属性:
attr(q4, ".Environment")
#> <environment: R_GlobalEnv>但是不建议使用上面的函数,而是使用get_expr()和get_env()来获取表达式和环境:
get_expr(q4)
#> x + y + z
get_env(q4)
#> <environment: R_GlobalEnv>Nested quosures
准引用支持在“表达式”中引入quosures,这是一种高级技术,使得创建嵌套quosures变得可能。例如下面的“表达式”中嵌套了两个短语。
q2 <- new_quosure(expr(x), env(x = 1))
q3 <- new_quosure(expr(x), env(x = 10))
x <- expr(!!q2 + !!q3)它可以被正确地评估,但是如果打印它,你会发现它的“formula”形式:
eval_tidy(x)
#> [1] 11
x
#> (~x) + ~x可以使用rlang::expr_print()来更好的展示,在终端中根据不同环境源显示不同颜色:
expr_print(x)
#> (^x) + (^x)Data masks
本节介绍数据掩码(data mask)相关内容,这是一种同时在“环境”与“数据框构成的环境”中评估“表达式”的技术。它的核心思想与base R中的with(),subset()和transform()类似,被广泛应用在“tidyverse”系列包中。
注意:enquo()保证了能在不同环境中正确评估“表达式”中的变量,expr()不能,这是一个重要的区别。但是本节所有示例中的enquo()与expr()都可以替换,不影响结果。
Basics
数据掩码允许你混合环境来源和数据框来源的变量。你可以将数据框当作环境变量传递给eval_tidy()的第二个参数。
q1 <- new_quosure(expr(x * y), env(x = 100))
df <- data.frame(y = 1:10)
eval_tidy(q1, df)
#> [1] 100 200 300 400 500 600 700 800 900 1000上面的代码可能有些难以理解,我们可以作一些拆分:
x <- 100
df <- data.frame(y = 1:10)
eval_tidy(expr(x * y), df)
#> [1] 100 200 300 400 500 600 700 800 900 1000稍加修改,改写为类似base::with()的函数:
with2 <- function(data, expr) {
expr <- enquo(expr)
eval_tidy(expr, data)
}
with2(df, x * y)
#> [1] 100 200 300 400 500 600 700 800 900 1000base::eval()可以实现类似的效果,传递数据框到第二个参数,环境到第三个参数。
with3 <- function(data, expr) {
expr <- substitute(expr)
eval(expr, data, caller_env())
}Pronouns
数据掩码会引起歧义。例如,在以下代码中,除非你知道df中包含哪些变量,否则你无法知道x是来自数据掩码还是环境。
with2(df, x)
#> [1] 100为了解决歧义问题,数据掩码提供了两个声明:.data和.env。
.data$x表示数据掩码中的变量x。.env$x表示环境中的变量x。
x <- 1
df <- data.frame(x = 2)
with2(df, .data$x)
#> [1] 2
with2(df, .env$x)
#> [1] 1对于两个声明,你可以使用[[,但是要注意它们是特殊的对象,和真实的数据框、环境不同。例如,如果找不到变量,它会抛出错误:
df$y
#> NULL
with2(df, .data$y)
#> Error in `.data$y`:
#> ! Column `y` not found in `.data`.Application: subset()
下面是subset()的使用场景之一:直接通过某个“表达式”进行过滤数据框的行。
sample_df <- data.frame(a = 1:5, b = 5:1, c = c(5, 3, 1, 4, 1))
# Shorthand for sample_df[sample_df$a >= 4, ]
subset(sample_df, a >= 4)
#> a b c
#> 4 4 2 4
#> 5 5 1 1
# Shorthand for sample_df[sample_df$b == sample_df$c, ]
subset(sample_df, b == c)
#> a b c
#> 1 1 5 5
#> 5 5 1 1subset()的核心逻辑是:
- 两个参数:数据框
data和“表达式”rows。 - 在数据框
data中,评估rows,并返回结果逻辑向量。 - 根据逻辑向量,返回数据框的行。
subset2 <- function(data, rows) {
rows <- enquo(rows)
rows_val <- eval_tidy(rows, data)
stopifnot(is.logical(rows_val))
data[rows_val, , drop = FALSE]
}
subset2(sample_df, a >= 4)
#> a b c
#> 4 4 2 4
#> 5 5 1 1Application: transform()
transform()函数类似dplyr::mutate(),可以在数据框中添加新的一列。
df <- data.frame(x = c(2, 3, 1), y = runif(3))
transform(df, x = -x, y2 = 2 * y)
#> x y y2
#> 1 -2 0.9811686 1.9623372
#> 2 -3 0.4927797 0.9855595
#> 3 -1 0.2881747 0.5763493下面是transform()的简单等价实现:
transform2 <- function(.data, ...) {
dots <- enquos(...)
for (i in seq_along(dots)) {
name <- names(dots)[[i]]
dot <- dots[[i]]
.data[[name]] <- eval_tidy(dot, .data)
}
.data
}
transform2(df, x2 = x * 2, y = -y)
#> x y x2
#> 1 2 -0.9811686 4
#> 2 3 -0.4927797 6
#> 3 1 -0.2881747 2Application: select()
数据掩码不总是作用于数据框,也可以是list。这是subset()的另一个使用场景——根据“表达式”选择某些列——的底层逻辑。
df <- data.frame(a = 1, b = 2, c = 3, d = 4, e = 5)
subset(df, select = b:d)
#> b c d
#> 1 2 3 4它的关键思想是创建一个有name属性的list,list的每个元素是对应列的位置索引。
vars <- as.list(set_names(seq_along(df), names(df)))
str(vars)
#> List of 5
#> $ a: int 1
#> $ b: int 2
#> $ c: int 3
#> $ d: int 4
#> $ e: int 5然后在list中进行评估,返回位置索引:
select2 <- function(.data, ...) {
dots <- enquos(...)
vars <- as.list(set_names(seq_along(.data), names(.data)))
cols <- unlist(map(dots, eval_tidy, vars))
.data[, cols, drop = FALSE]
}
select2(df, b:d)
#> b c d
#> 1 2 3 4Using tidy evaluation
本节将会给出一些使用tidy evaluation的函数例子。
Quoting and unquoting
嵌套函数传递“表达式”时,函数内部一定要先使用enquo()引用“表达式”,然后再使用!!解引用。
假设有这样一个随机排序数据框的函数:
resample <- function(df, n) {
idx <- sample(nrow(df), n, replace = TRUE)
df[idx, , drop = FALSE]
}现在要结合上面的subset2()函数,同时实现筛选和随机排序:
subsample <- function(df, cond, n = nrow(df)) {
df <- subset2(df, cond)
resample(df, n)
}
df <- data.frame(x = c(1, 1, 1, 2, 2), y = 1:5)
rm(x)
subsample(df, x == 1)
#> Error: object 'x' not found由于subsample()函数没有对cond进行引用,所以导致subset2()无法正确评估x。这种嵌套传递“表达式”需要“引用-解引用”式的中间步骤。
subsample <- function(df, cond, n = nrow(df)) {
cond <- enquo(cond)
df <- subset2(df, !!cond)
resample(df, n)
}
subsample(df, x == 1)
#> x y
#> 1 1 1
#> 1.1 1 1
#> 2 1 2Handling ambiguity
当既有指向数据框的参数也有指向环境的参数时,会导致引用歧义,产生不符合预期的结果。
假设现在有一个根据提供的参数值来过滤数据框的函数。
threshold_x <- function(df, val) {
subset2(df, x >= val)
}- 当
x在环境中存在,但不在数据框中时:
x <- 10
no_x <- data.frame(y = 1:3)
threshold_x(no_x, 2)
#> y
#> 1 1
#> 2 2
#> 3 3- 当数据框中有
val列时:
has_val <- data.frame(x = 1:3, val = 9:11)
threshold_x(has_val, 2)
#> [1] x val
#> <0 rows> (or 0-length row.names)特殊情况会产生不符合预期的结果,所以我们需要声明参数来源。
threshold_x <- function(df, val) {
subset2(df, .data$x >= .env$val)
}
x <- 10
threshold_x(no_x, 2)
#> Error in `.data$x`:
#> ! Column `x` not found in `.data`.
threshold_x(has_val, 2)
#> x val
#> 2 2 10
#> 3 3 11通常使用.env声明的参数也可以使用!!来替代:
threshold_x <- function(df, val) {
subset2(df, .data$x >= !!val)
}二者的区别在于何时评估参数val:如果使用!!,val会被enquo()评估,如果使用.env,val会被eval_tidy()评估。这种差别的影响微乎其微。
Quoting and ambiguity
本小节的内容,没有搞懂作者的意图。
将上面的threshold_x()函数的筛选列由固定的x改为参数var提供,可以使用.data[[var]]来访问列:
threshold_var <- function(df, var, val) {
var <- as_string(ensym(var))
subset2(df, .data[[var]] >= !!val)
}
df <- data.frame(x = 1:10)
threshold_var(df, x, 8)
#> x
#> 8 8
#> 9 9
#> 10 10也可以使用enquo()和!!来处理列:
threshold_expr <- function(df, expr, val) {
expr <- enquo(expr)
subset2(df, !!expr >= !!val)
}
threshold_expr(df, x, 8)
#> x
#> 8 8
#> 9 9
#> 10 10Base evaluation
本节介绍base R中替代tidy evaluation的两种常用函数:
substitute()和在调用环境中评估(base::subset()使用的函数)。match.call()控制调用和在调用环境中评估(stats::lm()使用的函数)。
substitute()
base R中最常见的非标准性评估(NSE)模式是substitute() + eval()。下面是使用这种模式编写的subset()。二者的主要区别是评估的环境不同,前者在调用环境中评估,后者在“表达式”定义时的环境中评估。
subset_base <- function(data, rows) {
rows <- substitute(rows)
rows_val <- eval(rows, data, caller_env())
stopifnot(is.logical(rows_val))
data[rows_val, , drop = FALSE]
}
subset_tidy <- function(data, rows) {
rows <- enquo(rows)
rows_val <- eval_tidy(rows, data, env = caller_env())
stopifnot(is.logical(rows_val))
data[rows_val, , drop = FALSE]
}Programming with subset()
subset()的文档中由这样的警告:
This is a convenience function intended for use interactively. For programming it is better to use the standard subsetting functions like [, and in particular the non-standard evaluation of argument subset can have unanticipated consequences.
它存在三个主要问题:
base::subset()总是在调用环境中评估rows,这可能会导致评估错误。f1 <- function(df, ...) { xval <- 3 subset_base(df, ...) # subset_tidy(df, ...) } my_df <- data.frame(x = 1:3, y = 3:1) xval <- 1 f1(my_df, x == xval) #> x y #> 3 3 1这也意味着
subset_base()类型的函数不能与map()或lapply()一起使用。local({ zzz <- 2 dfs <- list(data.frame(x = 1:3), data.frame(x = 4:6)) lapply(dfs, subset_base, x == zzz) }) #> Error in eval(rows, data, caller_env()): object 'zzz' not found从另一个函数调用
subset()需要注意:你必须使用substitute()来捕获对substitute()完整表达式的调用,然后进行求值。这段代码很难理解,因为substitute()没有使用语法标记来取消引用。f2 <- function(df1, expr) { call <- substitute(subset_base(df1, expr)) expr_print(call) eval(call, caller_env()) } my_df <- data.frame(x = 1:3, y = 3:1) f2(my_df, x == 1) #> subset_base(my_df, x == 1) #> x y #> 1 1 3eval()不提供任何“声明”,所以无法准确区分“表达式”来源。据我所知,除非手动检查df中是否存在z变量,否则无法确保以下函数的安全。f3 <- function(df) { call <- substitute(subset_base(df, z > 0)) expr_print(call) eval(call, caller_env()) } my_df <- data.frame(x = 1:3, y = 3:1) z <- -1 f3(my_df) #> subset_base(my_df, z > 0) #> [1] x y #> <0 rows> (or 0-length row.names)
What about [?
既然tidy-eval很复杂,为什么不直接使用[?首先,[只能交互使用,不能应用在函数中,会不具有通用性。其次,相较于[,subset()有两个有点:
它默认设置了
drop = FALSE,保证返回的始终是数据框。它丢掉了条件是
NA的行。
这意味着,subset(df, x == y) 不等于df[x == y, ],而是等价于df[x == y & !is.na(x == y), , drop = FALSE]。而且,类似subset()的函数,如dplyr::filter()甚至可以将R语言的过滤规则转化为SQL语言,使得它在编程方面应用广泛。
match.call()
base R中另外一种NSE模式是使用match.call()。与substitute()类似,都会捕获”表达式“,但match.call()会捕获完整的调用”表达式“,并修改然后评估它。”rlang“中没有与其等价的函数。
g <- function(x, y, z) {
match.call()
}
g(1, 2, z = 3)
#> g(x = 1, y = 2, z = 3)match.call()的一个重要有应用是write.csv(),write.csv()捕获调用write.table()的表达式,然后修改参数,最后评估:
write.csv <- function(...) {
call <- match.call(write.table, expand.dots = TRUE)
call[[1]] <- quote(write.table)
call$sep <- ","
call$dec <- "."
eval(call, parent.frame())
}但是也可以不使用NSE直接实现这种功能:
write.csv <- function(...) {
write.table(..., sep = ",", dec = ".")
}Wrapping modelling functions
match.call()的另一个重要应用是lm()。但这一技术同时也导致打印捕获的”formula“不完整。让我们思考下面这一简单地对lm()的包装:
lm2 <- function(formula, data) {
lm(formula, data)
}这个包装函数可以成功运行,但无法准确捕获到”formula“:
lm2(mpg ~ disp, mtcars)
#>
#> Call:
#> lm(formula = formula, data = data)
#>
#> Coefficients:
#> (Intercept) disp
#> 29.59985 -0.04122为了修复这一错误,我们需要使用准引用技术。
lm3 <- function(formula, data, env = caller_env()) {
formula <- enexpr(formula)
data <- enexpr(data)
lm_call <- expr(lm(!!formula, data = !!data))
expr_print(lm_call)
eval(lm_call, env)
}
lm3(mpg ~ disp, mtcars)
#> lm(mpg ~ disp, data = mtcars)
#>
#> Call:
#> lm(formula = mpg ~ disp, data = mtcars)
#>
#> Coefficients:
#> (Intercept) disp
#> 29.59985 -0.04122当你想要封装一个base R中的NSE函数时,你需要注意下面三点:
使用
enexpr()捕获未评估的参数,使用caller_env()获取调用函数的env。使用
expr()与!!组合新的调用“表达式”。要在
caller_env()中评估新的“表达式”。
Evaluation environment
如果在lm3()中,对data进行了某种处理,如resample(),那么会导致data的环境由外部的调用环境变为内部的运行环境,最终导致报错。
resample_lm0 <- function(formula, data, env = caller_env()) {
formula <- enexpr(formula)
resample_data <- resample(data, n = nrow(data))
lm_call <- expr(lm(!!formula, data = resample_data))
expr_print(lm_call)
eval(lm_call, env)
}
df <- data.frame(x = 1:10, y = 5 + 3 * (1:10) + round(rnorm(10), 2))
resample_lm0(y ~ x, data = df)
#> lm(y ~ x, data = resample_data)
#> Error in eval(mf, parent.frame()): object 'resample_data' not found有两种方法可以避免报错:
直接将
data解引用进行传递,但这会导致打印出的data很奇怪:resample_lm1 <- function(formula, data, env = caller_env()) { formula <- enexpr(formula) resample_data <- resample(data, n = nrow(data)) lm_call <- expr(lm(!!formula, data = !!resample_data)) expr_print(lm_call) eval(lm_call, env) } resample_lm1(y ~ x, data = df)$call #> lm(y ~ x, data = <df[,2]>) #> lm(formula = y ~ x, data = list(x = c(10L, 7L, 6L, 8L, 7L, 6L, #> 9L, 5L, 7L, 6L), y = c(34.45, 24.36, 22.83, 28.5, 24.36, 22.83, #> 32.83, 19.01, 24.36, 22.83)))将修改后的
data重新添加到caller_env()中:resample_lm2 <- function(formula, data, env = caller_env()) { formula <- enexpr(formula) resample_data <- resample(data, n = nrow(data)) lm_env <- env(env, resample_data = resample_data) lm_call <- expr(lm(!!formula, data = resample_data)) expr_print(lm_call) eval(lm_call, lm_env) } resample_lm2(y ~ x, data = df) #> lm(y ~ x, data = resample_data) #> #> Call: #> lm(formula = y ~ x, data = resample_data) #> #> Coefficients: #> (Intercept) x #> 4.961 2.915
